Micron Document
🎖️GitЯра🎖️

Commit 97ec245a70ef792e54a9e21a2d79c4832ced1d25


Parents : a871dad
Author : James Rich <james.a.rich@gmail.com>
Date : 2026-05-13T11:30:44-05:00

feat(node-list): add density switching with compact layout and field toggles

Implement the Node List Layout feature (Phases 1-6):

- Add NodeListDensity enum (COMPLETE/COMPACT) in core:model
- Add 10 DataStore preferences for density and field toggles
- Create NodeItemCompact with two-column layout, adaptive chip sizing,
and toggle-driven fields (power, last heard, location, hops, signal,
channel, role, telemetry)
- Add accessibility semantics (mergeDescendants, contentDescription,
Role.Button) to both NodeItem and NodeItemCompact
- Create NodeLayoutSettings with SegmentedButton density picker and
9 SwitchPreference toggles for compact mode
- Integrate settings into Android and Desktop settings screens
- Wire NodeListScreen to delegate between layouts based on density
- Create NodeListHelp ModalBottomSheet with signal quality legend
- Add help IconButton to NodeListScreen app bar
- Add ~23 new string resources for layout settings and help text
- Update FakeUiPrefs for test compatibility

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Changes

17 files changed, 1238 insertions(+), 14 deletions(-)


Diff

diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 133caa1862..39ad7de556 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -809,6 +809,28 @@ node_filter_placeholder
node_filter_show_ignored
node_filter_title
node_id
+node_layout_channel
+node_layout_compact
+node_layout_complete
+node_layout_complete_description
+node_layout_device_role
+node_layout_distance_and_bearing
+node_layout_help_signal_bad
+node_layout_help_signal_fair
+node_layout_help_signal_good
+node_layout_help_signal_indicator
+node_layout_help_signal_none
+node_layout_hops_away
+node_layout_last_heard_time
+node_layout_log_icons
+node_layout_no_preview_available
+node_layout_power
+node_layout_relative_last_heard
+node_layout_section_title
+node_layout_signal_direct_only
+node_layout_signal_quality_indicator
+node_list_help_node_details
+node_list_help_title
node_number
node_sort_alpha
node_sort_button

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeListDensity.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeListDensity.kt
new file mode 100644
index 0000000000..a4ed063aa8
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeListDensity.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model
+
+/** Controls the visual density of the node list. */
+enum class NodeListDensity {
+ /** Full-detail layout showing all available data fields. */
+ COMPLETE,
+
+ /** Condensed layout with user-configurable field toggles. */
+ COMPACT,
+}

diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPreferences.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPreferences.kt
new file mode 100644
index 0000000000..bf5564f265
--- /dev/null
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPreferences.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.prefs.ui
+
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+
+/**
+ * DataStore preference keys for node list layout configuration. Key strings are used directly by DataStore — do not
+ * change without migration.
+ */
+enum class NodeListLayoutPreferences(val key: String, val defaultBoolean: Boolean = true) {
+ NODE_LIST_DENSITY("node-list-density", defaultBoolean = true),
+ SHOULD_SHOW_POWER("node-layout-show-power", defaultBoolean = true),
+ SHOULD_SHOW_LAST_HEARD("node-layout-show-last-heard", defaultBoolean = true),
+ LAST_HEARD_IS_RELATIVE("node-layout-last-heard-relative", defaultBoolean = false),
+ SHOULD_SHOW_LOCATION("node-layout-show-location", defaultBoolean = true),
+ SHOULD_SHOW_HOPS("node-layout-show-hops", defaultBoolean = true),
+ SHOULD_SHOW_SIGNAL("node-layout-show-signal", defaultBoolean = true),
+ SHOULD_SHOW_CHANNEL("node-layout-show-channel", defaultBoolean = true),
+ SHOULD_SHOW_ROLE("node-layout-show-role", defaultBoolean = true),
+ SHOULD_SHOW_TELEMETRY("node-layout-show-telemetry", defaultBoolean = true),
+ ;
+
+ companion object {
+ val KEY_DENSITY = stringPreferencesKey(NODE_LIST_DENSITY.key)
+ val KEY_SHOW_POWER = booleanPreferencesKey(SHOULD_SHOW_POWER.key)
+ val KEY_SHOW_LAST_HEARD = booleanPreferencesKey(SHOULD_SHOW_LAST_HEARD.key)
+ val KEY_LAST_HEARD_RELATIVE = booleanPreferencesKey(LAST_HEARD_IS_RELATIVE.key)
+ val KEY_SHOW_LOCATION = booleanPreferencesKey(SHOULD_SHOW_LOCATION.key)
+ val KEY_SHOW_HOPS = booleanPreferencesKey(SHOULD_SHOW_HOPS.key)
+ val KEY_SHOW_SIGNAL = booleanPreferencesKey(SHOULD_SHOW_SIGNAL.key)
+ val KEY_SHOW_CHANNEL = booleanPreferencesKey(SHOULD_SHOW_CHANNEL.key)
+ val KEY_SHOW_ROLE = booleanPreferencesKey(SHOULD_SHOW_ROLE.key)
+ val KEY_SHOW_TELEMETRY = booleanPreferencesKey(SHOULD_SHOW_TELEMETRY.key)
+ }
+}

diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
index ec4dc0b205..45fe27b220 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
@@ -181,6 +181,98 @@ class UiPrefsImpl(
private fun provideLocationKey(nodeNum: Int) = "provide-location-$nodeNum"
+ // Node list layout preferences
+
+ override val nodeListDensity: StateFlow<String> =
+ dataStore.data
+ .map { it[NodeListLayoutPreferences.KEY_DENSITY] ?: "COMPLETE" }
+ .stateIn(scope, SharingStarted.Eagerly, "COMPLETE")
+
+ override fun setNodeListDensity(value: String) {
+ scope.launch { dataStore.edit { it[NodeListLayoutPreferences.KEY_DENSITY] = value } }
+ }
+
+ override val shouldShowPower: StateFlow<Boolean> =
+ dataStore.data
+ .map { it[NodeListLayoutPreferences.KEY_SHOW_POWER] ?: true }
+ .stateIn(scope, SharingStarted.Eagerly, true)
+
+ override fun setShouldShowPower(value: Boolean) {
+ scope.launch { dataStore.edit { it[NodeListLayoutPreferences.KEY_SHOW_POWER] = value } }
+ }
+
+ override val shouldShowLastHeard: StateFlow<Boolean> =
+ dataStore.data
+ .map { it[NodeListLayoutPreferences.KEY_SHOW_LAST_HEARD] ?: true }
+ .stateIn(scope, SharingStarted.Eagerly, true)
+
+ override fun setShouldShowLastHeard(value: Boolean) {
+ scope.launch { dataStore.edit { it[NodeListLayoutPreferences.KEY_SHOW_LAST_HEARD] = value } }
+ }
+
+ override val lastHeardIsRelative: StateFlow<Boolean> =
+ dataStore.data
+ .map { it[NodeListLayoutPreferences.KEY_LAST_HEARD_RELATIVE] ?: false }
+ .stateIn(scope, SharingStarted.Eagerly, false)
+
+ override fun setLastHeardIsRelative(value: Boolean) {
+ scope.launch { dataStore.edit { it[NodeListLayoutPreferences.KEY_LAST_HEARD_RELATIVE] = value } }
+ }
+
+ override val shouldShowLocation: StateFlow<Boolean> =
+ dataStore.data
+ .map { it[NodeListLayoutPreferences.KEY_SHOW_LOCATION] ?: true }
+ .stateIn(scope, SharingStarted.Eagerly, true)
+
+ override fun setShouldShowLocation(value: Boolean) {
+ scope.launch { dataStore.edit { it[NodeListLayoutPreferences.KEY_SHOW_LOCATION] = value } }
+ }
+
+ override val shouldShowHops: StateFlow<Boolean> =
+ dataStore.data
+ .map { it[NodeListLayoutPreferences.KEY_SHOW_HOPS] ?: true }
+ .stateIn(scope, SharingStarted.Eagerly, true)
+
+ override fun setShouldShowHops(value: Boolean) {
+ scope.launch { dataStore.edit { it[NodeListLayoutPreferences.KEY_SHOW_HOPS] = value } }
+ }
+
+ override val shouldShowSignal: StateFlow<Boolean> =
+ dataStore.data
+ .map { it[NodeListLayoutPreferences.KEY_SHOW_SIGNAL] ?: true }
+ .stateIn(scope, SharingStarted.Eagerly, true)
+
+ override fun setShouldShowSignal(value: Boolean) {
+ scope.launch { dataStore.edit { it[NodeListLayoutPreferences.KEY_SHOW_SIGNAL] = value } }
+ }
+
+ override val shouldShowChannel: StateFlow<Boolean> =
+ dataStore.data
+ .map { it[NodeListLayoutPreferences.KEY_SHOW_CHANNEL] ?: true }
+ .stateIn(scope, SharingStarted.Eagerly, true)
+
+ override fun setShouldShowChannel(value: Boolean) {
+ scope.launch { dataStore.edit { it[NodeListLayoutPreferences.KEY_SHOW_CHANNEL] = value } }
+ }
+
+ override val shouldShowRole: StateFlow<Boolean> =
+ dataStore.data
+ .map { it[NodeListLayoutPreferences.KEY_SHOW_ROLE] ?: true }
+ .stateIn(scope, SharingStarted.Eagerly, true)
+
+ override fun setShouldShowRole(value: Boolean) {
+ scope.launch { dataStore.edit { it[NodeListLayoutPreferences.KEY_SHOW_ROLE] = value } }
+ }
+
+ override val shouldShowTelemetry: StateFlow<Boolean> =
+ dataStore.data
+ .map { it[NodeListLayoutPreferences.KEY_SHOW_TELEMETRY] ?: true }
+ .stateIn(scope, SharingStarted.Eagerly, true)
+
+ override fun setShouldShowTelemetry(value: Boolean) {
+ scope.launch { dataStore.edit { it[NodeListLayoutPreferences.KEY_SHOW_TELEMETRY] = value } }
+ }
+
companion object {
val KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF = booleanPreferencesKey("has_shown_not_paired_warning")
val KEY_SHOW_QUICK_CHAT_PREF = booleanPreferencesKey("show-quick-chat")

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index 92f9381e39..1e0cbd29d7 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -151,6 +151,49 @@ interface UiPrefs {
fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean>
fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean)
+
+ // Node list layout preferences
+
+ /** Active density mode stored as the enum name (e.g. "COMPLETE", "COMPACT"). */
+ val nodeListDensity: StateFlow<String>
+
+ fun setNodeListDensity(value: String)
+
+ val shouldShowPower: StateFlow<Boolean>
+
+ fun setShouldShowPower(value: Boolean)
+
+ val shouldShowLastHeard: StateFlow<Boolean>
+
+ fun setShouldShowLastHeard(value: Boolean)
+
+ val lastHeardIsRelative: StateFlow<Boolean>
+
+ fun setLastHeardIsRelative(value: Boolean)
+
+ val shouldShowLocation: StateFlow<Boolean>
+
+ fun setShouldShowLocation(value: Boolean)
+
+ val shouldShowHops: StateFlow<Boolean>
+
+ fun setShouldShowHops(value: Boolean)
+
+ val shouldShowSignal: StateFlow<Boolean>
+
+ fun setShouldShowSignal(value: Boolean)
+
+ val shouldShowChannel: StateFlow<Boolean>
+
+ fun setShouldShowChannel(value: Boolean)
+
+ val shouldShowRole: StateFlow<Boolean>
+
+ fun setShouldShowRole(value: Boolean)
+
+ val shouldShowTelemetry: StateFlow<Boolean>
+
+ fun setShouldShowTelemetry(value: Boolean)
}
/** Reactive interface for notification preferences. */

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 57a23594a4..f38f83b11e 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -839,6 +839,28 @@
<string name="node_filter_show_ignored">Only show ignored Nodes</string>
<string name="node_filter_title">Filter by</string>
<string name="node_id">Node ID</string>
+ <string name="node_layout_channel">Channel</string>
+ <string name="node_layout_compact">Compact</string>
+ <string name="node_layout_complete">Complete</string>
+ <string name="node_layout_complete_description">The Complete layout displays all available node data. Fields with no data are automatically hidden.</string>
+ <string name="node_layout_device_role">Device Role</string>
+ <string name="node_layout_distance_and_bearing">Distance and Bearing</string>
+ <string name="node_layout_help_signal_bad">Signal is poor. SNR is above −18 dB and RSSI is above −125 dBm.</string>
+ <string name="node_layout_help_signal_fair">Signal is moderate. SNR is above −12 dB and RSSI is above −120 dBm.</string>
+ <string name="node_layout_help_signal_good">Signal is strong. SNR is above −7 dB and RSSI is above −115 dBm.</string>
+ <string name="node_layout_help_signal_indicator">Combines SNR and RSSI into a quality level shown as a colored icon with description. Displayed in the Complete layout only.</string>
+ <string name="node_layout_help_signal_none">No usable signal detected. Below all quality thresholds.</string>
+ <string name="node_layout_hops_away">Hops Away</string>
+ <string name="node_layout_last_heard_time">Last Heard Time</string>
+ <string name="node_layout_log_icons">Log Icons</string>
+ <string name="node_layout_no_preview_available">No nodes available for preview.</string>
+ <string name="node_layout_power">Power</string>
+ <string name="node_layout_relative_last_heard">Relative Last Heard Time</string>
+ <string name="node_layout_section_title">Node Layout</string>
+ <string name="node_layout_signal_direct_only">Signal (Direct Only)</string>
+ <string name="node_layout_signal_quality_indicator">Signal Quality Indicator</string>
+ <string name="node_list_help_node_details">Node Details</string>
+ <string name="node_list_help_title">Node List Help</string>
<string name="node_number">Node Number</string>
<string name="node_sort_alpha">A-Z</string>
<string name="node_sort_button">Node sorting options</string>

diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index 7122b1cc9d..bef3d2be13 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -187,6 +187,66 @@ class FakeUiPrefs : UiPrefs {
override fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean) {
nodeLocationEnabled.getOrPut(nodeNum) { MutableStateFlow(provide) }.value = provide
}
+
+ override val nodeListDensity = MutableStateFlow("COMPLETE")
+
+ override fun setNodeListDensity(value: String) {
+ nodeListDensity.value = value
+ }
+
+ override val shouldShowPower = MutableStateFlow(true)
+
+ override fun setShouldShowPower(value: Boolean) {
+ shouldShowPower.value = value
+ }
+
+ override val shouldShowLastHeard = MutableStateFlow(true)
+
+ override fun setShouldShowLastHeard(value: Boolean) {
+ shouldShowLastHeard.value = value
+ }
+
+ override val lastHeardIsRelative = MutableStateFlow(false)
+
+ override fun setLastHeardIsRelative(value: Boolean) {
+ lastHeardIsRelative.value = value
+ }
+
+ override val shouldShowLocation = MutableStateFlow(true)
+
+ override fun setShouldShowLocation(value: Boolean) {
+ shouldShowLocation.value = value
+ }
+
+ override val shouldShowHops = MutableStateFlow(true)
+
+ override fun setShouldShowHops(value: Boolean) {
+ shouldShowHops.value = value
+ }
+
+ override val shouldShowSignal = MutableStateFlow(true)
+
+ override fun setShouldShowSignal(value: Boolean) {
+ shouldShowSignal.value = value
+ }
+
+ override val shouldShowChannel = MutableStateFlow(true)
+
+ override fun setShouldShowChannel(value: Boolean) {
+ shouldShowChannel.value = value
+ }
+
+ override val shouldShowRole = MutableStateFlow(true)
+
+ override fun setShouldShowRole(value: Boolean) {
+ shouldShowRole.value = value
+ }
+
+ override val shouldShowTelemetry = MutableStateFlow(true)
+
+ override fun setShouldShowTelemetry(value: Boolean) {
+ shouldShowTelemetry.value = value
+ }
}
class FakeMapPrefs : MapPrefs {

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
index 84dd70d1f7..9d6aba2c7d 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
@@ -40,6 +40,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
@@ -89,6 +93,7 @@ import org.meshtastic.core.ui.icon.AirUtilization
import org.meshtastic.core.ui.icon.ChannelUtilization
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Notes
+import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.proto.Config
private const val ACTIVE_ALPHA = 0.5f
@@ -151,7 +156,29 @@ fun NodeItem(
}
}
- Card(modifier = modifier.fillMaxWidth(), colors = cardColors) {
+ val nodeDescription =
+ buildNodeDescription(
+ name = originalLongName,
+ isOnline = thatNode.isOnline,
+ isFavorite = isFavorite,
+ lastHeard = thatNode.lastHeard,
+ role = thatNode.user.role.name,
+ hopsAway = thatNode.hopsAway,
+ batteryLevel = thatNode.batteryLevel,
+ distance = distance,
+ snr = thatNode.snr,
+ rssi = thatNode.rssi,
+ viaMqtt = thatNode.viaMqtt,
+ )
+
+ Card(
+ modifier =
+ modifier.fillMaxWidth().semantics(mergeDescendants = true) {
+ contentDescription = nodeDescription
+ role = Role.Button
+ },
+ colors = cardColors,
+ ) {
Column(
modifier =
Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick).fillMaxWidth().padding(12.dp),
@@ -459,3 +486,32 @@ private fun NodeItemFooter(thatNode: Node, contentColor: Color) {
NodeIdInfo(id = thatNode.user.id.ifEmpty { "???" }, contentColor = contentColor)
}
}
+
+/** Builds a TalkBack-friendly description aggregating node state. Shared between [NodeItem] and `NodeItemCompact`. */
+@Suppress("LongParameterList")
+fun buildNodeDescription(
+ name: String,
+ isOnline: Boolean,
+ isFavorite: Boolean,
+ lastHeard: Int,
+ role: String,
+ hopsAway: Int,
+ batteryLevel: Int?,
+ distance: String?,
+ snr: Float,
+ rssi: Int,
+ viaMqtt: Boolean,
+): String = buildString {
+ append(name)
+ append(if (isOnline) ", online" else ", offline")
+ if (isFavorite) append(", favorite")
+ if (lastHeard > 0) append(", last heard ${formatAgo(lastHeard)}")
+ append(", role $role")
+ if (hopsAway > 0) append(", $hopsAway hops away")
+ batteryLevel?.let { if (it in 1..100) append(", battery $it%") }
+ distance?.let { append(", $it away") }
+ if (hopsAway == 0 && !viaMqtt && snr < 100f && rssi < 0) {
+ val quality = determineSignalQuality(snr, rssi)
+ append(", signal ${quality.name.lowercase()}")
+ }
+}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItemCompact.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItemCompact.kt
new file mode 100644
index 0000000000..1f806144ef
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItemCompact.kt
@@ -0,0 +1,368 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+@file:Suppress("MagicNumber")
+
+package org.meshtastic.feature.node.component
+
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.resources.vectorResource
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceType
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.isUnmessageableRole
+import org.meshtastic.core.model.util.toDistanceString
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.unknown_username
+import org.meshtastic.core.ui.component.ChannelInfo
+import org.meshtastic.core.ui.component.DistanceInfo
+import org.meshtastic.core.ui.component.HopsInfo
+import org.meshtastic.core.ui.component.IconInfo
+import org.meshtastic.core.ui.component.LastHeardInfo
+import org.meshtastic.core.ui.component.MaterialBatteryInfo
+import org.meshtastic.core.ui.component.NodeChip
+import org.meshtastic.core.ui.component.NodeKeyStatusIcon
+import org.meshtastic.core.ui.component.RoleInfo
+import org.meshtastic.core.ui.component.determineSignalQuality
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.PinDrop
+import org.meshtastic.core.ui.icon.Temperature
+import org.meshtastic.proto.Config
+
+private const val ACTIVE_ALPHA = 0.5f
+private const val INACTIVE_ALPHA = 0.2f
+private const val LINE_COUNT_BASE = 1
+private const val CHIP_MIN_DP = 36
+private const val CHIP_MAX_DP = 70
+private const val CHIP_PER_LINE_DP = 24
+
+@Composable
+@Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod")
+fun NodeItemCompact(
+ thisNode: Node?,
+ thatNode: Node,
+ distanceUnits: Int,
+ connectionState: ConnectionState,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit = {},
+ onLongClick: (() -> Unit)? = null,
+ deviceType: DeviceType? = null,
+ isActive: Boolean = false,
+ showPower: Boolean = true,
+ showLastHeard: Boolean = true,
+ showLocation: Boolean = true,
+ showHops: Boolean = true,
+ showSignal: Boolean = true,
+ showChannel: Boolean = true,
+ showRole: Boolean = true,
+ showTelemetry: Boolean = true,
+) {
+ val longName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) }
+ val isFavorite = thatNode.isFavorite
+ val isIgnored = thatNode.isIgnored
+ val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num }
+ val system =
+ remember(distanceUnits) {
+ Config.DisplayConfig.DisplayUnits.fromValue(distanceUnits) ?: Config.DisplayConfig.DisplayUnits.METRIC
+ }
+ val distance =
+ remember(thisNode, thatNode) { thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system) }
+ val unmessageable =
+ remember(thatNode) {
+ when {
+ thatNode.user.is_unmessagable != null -> thatNode.user.is_unmessagable!!
+ else -> thatNode.user.role.isUnmessageableRole()
+ }
+ }
+
+ // Adaptive chip sizing based on active row count
+ val hasCombinedRow = showLocation || showHops || showSignal || showChannel || showRole || showTelemetry
+ val lineCount = LINE_COUNT_BASE + (if (showLastHeard) 1 else 0) + (if (hasCombinedRow) 1 else 0)
+ val chipHeight = maxOf(CHIP_MIN_DP.dp, minOf(CHIP_MAX_DP.dp, (CHIP_PER_LINE_DP * lineCount).dp))
+
+ var contentColor = MaterialTheme.colorScheme.onSurface
+ val cardColors =
+ if (isThisNode) {
+ thisNode?.colors?.second
+ } else {
+ thatNode.colors.second
+ }
+ ?.let {
+ val alpha = if (isActive) ACTIVE_ALPHA else INACTIVE_ALPHA
+ val containerColor = Color(it).copy(alpha = alpha)
+ contentColor = contentColorFor(containerColor)
+ CardDefaults.cardColors().copy(containerColor = containerColor, contentColor = contentColor)
+ } ?: CardDefaults.cardColors()
+
+ val style = if (thatNode.isUnknownUser) FontStyle.Italic else FontStyle.Normal
+
+ val nodeDescription =
+ buildNodeDescription(
+ name = longName,
+ isOnline = thatNode.isOnline,
+ isFavorite = isFavorite,
+ lastHeard = thatNode.lastHeard,
+ role = thatNode.user.role.name,
+ hopsAway = thatNode.hopsAway,
+ batteryLevel = thatNode.batteryLevel,
+ distance = distance,
+ snr = thatNode.snr,
+ rssi = thatNode.rssi,
+ viaMqtt = thatNode.viaMqtt,
+ )
+
+ Card(
+ modifier =
+ modifier.fillMaxWidth().semantics(mergeDescendants = true) {
+ contentDescription = nodeDescription
+ role = Role.Button
+ },
+ colors = cardColors,
+ ) {
+ Row(
+ modifier =
+ Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick)
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ // Column 1: NodeChip + optional battery
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
+ NodeChip(node = thatNode, modifier = Modifier.defaultMinSize(minHeight = chipHeight))
+ if (showPower && thatNode.batteryLevel != null) {
+ MaterialBatteryInfo(
+ level = thatNode.batteryLevel ?: 0,
+ voltage = thatNode.voltage ?: 0f,
+ contentColor = contentColor,
+ )
+ }
+ }
+
+ // Column 2: Content rows
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ // Row 1: Name (always visible)
+ CompactNameRow(
+ thatNode = thatNode,
+ isThisNode = isThisNode,
+ longName = longName,
+ style = style,
+ isIgnored = isIgnored,
+ isFavorite = isFavorite,
+ unmessageable = unmessageable,
+ connectionState = connectionState,
+ deviceType = deviceType,
+ contentColor = contentColor,
+ )
+
+ // Row 2: Last heard (toggle-dependent)
+ if (showLastHeard && thatNode.lastHeard > 0 && !isFutureDate(thatNode.lastHeard)) {
+ LastHeardInfo(lastHeard = thatNode.lastHeard, showLabel = false, contentColor = contentColor)
+ }
+
+ // Row 3: Combined icons (toggle-dependent)
+ CompactCombinedRow(
+ thatNode = thatNode,
+ isThisNode = isThisNode,
+ distance = distance,
+ showLocation = showLocation,
+ showHops = showHops,
+ showSignal = showSignal,
+ showChannel = showChannel,
+ showRole = showRole,
+ showTelemetry = showTelemetry,
+ contentColor = contentColor,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun CompactNameRow(
+ thatNode: Node,
+ isThisNode: Boolean,
+ longName: String,
+ style: FontStyle,
+ isIgnored: Boolean,
+ isFavorite: Boolean,
+ unmessageable: Boolean,
+ connectionState: ConnectionState,
+ deviceType: DeviceType?,
+ contentColor: Color,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ NodeKeyStatusIcon(
+ hasPKC = thatNode.hasPKC,
+ mismatchKey = thatNode.mismatchKey,
+ publicKey = thatNode.user.public_key,
+ modifier = Modifier.size(18.dp),
+ )
+ Text(
+ text = longName,
+ style = MaterialTheme.typography.titleMediumEmphasized.copy(fontStyle = style),
+ textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ NodeStatusIcons(
+ isThisNode = isThisNode,
+ isFavorite = isFavorite,
+ isMuted = thatNode.isMuted,
+ isUnmessageable = unmessageable,
+ connectionState = connectionState,
+ deviceType = deviceType,
+ contentColor = contentColor,
+ )
+ }
+}
+
+@Composable
+@Suppress("LongParameterList", "CyclomaticComplexMethod", "LongMethod")
+private fun CompactCombinedRow(
+ thatNode: Node,
+ isThisNode: Boolean,
+ distance: String?,
+ showLocation: Boolean,
+ showHops: Boolean,
+ showSignal: Boolean,
+ showChannel: Boolean,
+ showRole: Boolean,
+ showTelemetry: Boolean,
+ contentColor: Color,
+) {
+ val items = mutableListOf<@Composable () -> Unit>()
+
+ // Distance + Bearing
+ if (showLocation && distance != null && !isThisNode) {
+ items.add { DistanceInfo(distance = distance, contentColor = contentColor) }
+ }
+
+ // Hops Away (only when hopsAway > 0)
+ if (showHops && thatNode.hopsAway > 0) {
+ items.add { HopsInfo(hops = thatNode.hopsAway, contentColor = contentColor) }
+ }
+
+ // Signal (direct only: hopsAway == 0, snr valid, not via MQTT)
+ val hasDirectSignal = thatNode.hopsAway == 0 && thatNode.snr < 100f && !thatNode.viaMqtt && thatNode.rssi < 0
+ if (showSignal && hasDirectSignal) {
+ val quality = determineSignalQuality(thatNode.snr, thatNode.rssi)
+ items.add {
+ IconInfo(
+ icon = vectorResource(quality.icon),
+ contentDescription = stringResource(quality.nameRes),
+ contentColor = quality.color.invoke(),
+ text = stringResource(quality.nameRes),
+ )
+ }
+ }
+
+ // Channel (only when > 0)
+ if (showChannel && thatNode.channel > 0) {
+ items.add { ChannelInfo(channel = thatNode.channel, contentColor = contentColor) }
+ }
+
+ // Device Role
+ if (showRole) {
+ items.add { RoleInfo(role = thatNode.user.role, contentColor = contentColor) }
+ }
+
+ // Telemetry log icons
+ if (showTelemetry && hasTelemetryData(thatNode)) {
+ items.add { CompactTelemetryIcons(thatNode = thatNode, contentColor = contentColor) }
+ }
+
+ if (items.isNotEmpty()) {
+ Row(
+ modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ items.forEachIndexed { index, item ->
+ if (index > 0) {
+ VerticalDivider(modifier = Modifier.fillMaxHeight())
+ }
+ item()
+ }
+ }
+ }
+}
+
+@Composable
+private fun CompactTelemetryIcons(thatNode: Node, contentColor: Color) {
+ Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
+ if (thatNode.validPosition != null) {
+ Icon(
+ imageVector = MeshtasticIcons.PinDrop,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = contentColor,
+ )
+ }
+ if (thatNode.hasEnvironmentMetrics) {
+ Icon(
+ imageVector = MeshtasticIcons.Temperature,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = contentColor,
+ )
+ }
+ }
+}
+
+private fun hasTelemetryData(node: Node): Boolean = node.validPosition != null || node.hasEnvironmentMetrics
+
+private fun isFutureDate(lastHeard: Int): Boolean {
+ val nowSeconds = org.meshtastic.core.common.util.nowSeconds.toInt()
+ val oneYearSeconds = 365 * 24 * 60 * 60
+ return lastHeard > nowSeconds + oneYearSeconds
+}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeListHelp.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeListHelp.kt
new file mode 100644
index 0000000000..6e15e23aa0
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeListHelp.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.resources.vectorResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.node_layout_help_signal_bad
+import org.meshtastic.core.resources.node_layout_help_signal_fair
+import org.meshtastic.core.resources.node_layout_help_signal_good
+import org.meshtastic.core.resources.node_layout_help_signal_indicator
+import org.meshtastic.core.resources.node_layout_help_signal_none
+import org.meshtastic.core.resources.node_layout_signal_quality_indicator
+import org.meshtastic.core.resources.node_list_help_node_details
+import org.meshtastic.core.resources.node_list_help_title
+import org.meshtastic.core.ui.component.Quality
+
+private const val ICON_SIZE = 24
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NodeListHelp(onDismiss: () -> Unit) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
+ Column(
+ modifier =
+ Modifier.fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(text = stringResource(Res.string.node_list_help_title), style = MaterialTheme.typography.headlineSmall)
+
+ HorizontalDivider()
+
+ Text(
+ text = stringResource(Res.string.node_list_help_node_details),
+ style = MaterialTheme.typography.titleMedium,
+ )
+
+ SignalQualityEntry(Quality.GOOD, stringResource(Res.string.node_layout_help_signal_good))
+ SignalQualityEntry(Quality.FAIR, stringResource(Res.string.node_layout_help_signal_fair))
+ SignalQualityEntry(Quality.BAD, stringResource(Res.string.node_layout_help_signal_bad))
+ SignalQualityEntry(Quality.NONE, stringResource(Res.string.node_layout_help_signal_none))
+
+ HorizontalDivider()
+
+ Text(
+ text = stringResource(Res.string.node_layout_signal_quality_indicator),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Text(
+ text = stringResource(Res.string.node_layout_help_signal_indicator),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@Composable
+private fun SignalQualityEntry(quality: Quality, description: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = vectorResource(quality.icon),
+ contentDescription = stringResource(quality.nameRes),
+ modifier = Modifier.size(ICON_SIZE.dp),
+ tint = quality.color(),
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(text = stringResource(quality.nameRes), style = MaterialTheme.typography.titleSmall)
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt
index 648d6b1d84..b3568e2869 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt
@@ -22,6 +22,7 @@ import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.repository.UiPrefs
@Single
+@Suppress("TooManyFunctions")
open class NodeFilterPreferences constructor(private val uiPrefs: UiPrefs) {
open val includeUnknown = uiPrefs.includeUnknown
open val excludeInfrastructure = uiPrefs.excludeInfrastructure
@@ -30,6 +31,18 @@ open class NodeFilterPreferences constructor(private val uiPrefs: UiPrefs) {
open val showIgnored = uiPrefs.showIgnored
open val excludeMqtt = uiPrefs.excludeMqtt
+ // Node list layout preferences
+ open val nodeListDensity = uiPrefs.nodeListDensity
+ open val shouldShowPower = uiPrefs.shouldShowPower
+ open val shouldShowLastHeard = uiPrefs.shouldShowLastHeard
+ open val lastHeardIsRelative = uiPrefs.lastHeardIsRelative
+ open val shouldShowLocation = uiPrefs.shouldShowLocation
+ open val shouldShowHops = uiPrefs.shouldShowHops
+ open val shouldShowSignal = uiPrefs.shouldShowSignal
+ open val shouldShowChannel = uiPrefs.shouldShowChannel
+ open val shouldShowRole = uiPrefs.shouldShowRole
+ open val shouldShowTelemetry = uiPrefs.shouldShowTelemetry
+
open val nodeSortOption =
uiPrefs.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
@@ -37,6 +50,46 @@ open class NodeFilterPreferences constructor(private val uiPrefs: UiPrefs) {
uiPrefs.setNodeSort(option.ordinal)
}
+ open fun setNodeListDensity(value: String) {
+ uiPrefs.setNodeListDensity(value)
+ }
+
+ open fun setShouldShowPower(value: Boolean) {
+ uiPrefs.setShouldShowPower(value)
+ }
+
+ open fun setShouldShowLastHeard(value: Boolean) {
+ uiPrefs.setShouldShowLastHeard(value)
+ }
+
+ open fun setLastHeardIsRelative(value: Boolean) {
+ uiPrefs.setLastHeardIsRelative(value)
+ }
+
+ open fun setShouldShowLocation(value: Boolean) {
+ uiPrefs.setShouldShowLocation(value)
+ }
+
+ open fun setShouldShowHops(value: Boolean) {
+ uiPrefs.setShouldShowHops(value)
+ }
+
+ open fun setShouldShowSignal(value: Boolean) {
+ uiPrefs.setShouldShowSignal(value)
+ }
+
+ open fun setShouldShowChannel(value: Boolean) {
+ uiPrefs.setShouldShowChannel(value)
+ }
+
+ open fun setShouldShowRole(value: Boolean) {
+ uiPrefs.setShouldShowRole(value)
+ }
+
+ open fun setShouldShowTelemetry(value: Boolean) {
+ uiPrefs.setShouldShowTelemetry(value)
+ }
+
open fun toggleIncludeUnknown() {
uiPrefs.setIncludeUnknown(!includeUnknown.value)
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index 25b398c779..1a9335d4c8 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -36,6 +36,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -60,9 +61,11 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.NodeListDensity
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.resources.node_count_template
+import org.meshtastic.core.resources.node_list_help_title
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.resources.nodes_empty_disconnected_hint
import org.meshtastic.core.resources.nodes_empty_disconnected_title
@@ -73,12 +76,15 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
+import org.meshtastic.core.ui.icon.Info
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.feature.node.component.NodeContextMenu
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
+import org.meshtastic.feature.node.component.NodeItemCompact
+import org.meshtastic.feature.node.component.NodeListHelp
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -118,10 +124,26 @@ fun NodeListScreen(
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val deviceType by viewModel.deviceType.collectAsStateWithLifecycle()
+ val density by viewModel.nodeListDensity.collectAsStateWithLifecycle()
+ val showPower by viewModel.shouldShowPower.collectAsStateWithLifecycle()
+ val showLastHeard by viewModel.shouldShowLastHeard.collectAsStateWithLifecycle()
+ val lastHeardIsRelative by viewModel.lastHeardIsRelative.collectAsStateWithLifecycle()
+ val showLocation by viewModel.shouldShowLocation.collectAsStateWithLifecycle()
+ val showHops by viewModel.shouldShowHops.collectAsStateWithLifecycle()
+ val showSignal by viewModel.shouldShowSignal.collectAsStateWithLifecycle()
+ val showChannel by viewModel.shouldShowChannel.collectAsStateWithLifecycle()
+ val showRole by viewModel.shouldShowRole.collectAsStateWithLifecycle()
+ val showTelemetry by viewModel.shouldShowTelemetry.collectAsStateWithLifecycle()
+
val isScrollInProgress by remember {
derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) }
}
+ var showHelpSheet by remember { mutableStateOf(false) }
+ if (showHelpSheet) {
+ NodeListHelp(onDismiss = { showHelpSheet = false })
+ }
+
Scaffold(
topBar = {
MainAppBar(
@@ -131,7 +153,14 @@ fun NodeListScreen(
showNodeChip = false,
canNavigateUp = false,
onNavigateUp = {},
- actions = {},
+ actions = {
+ IconButton(onClick = { showHelpSheet = true }) {
+ Icon(
+ imageVector = MeshtasticIcons.Info,
+ contentDescription = stringResource(Res.string.node_list_help_title),
+ )
+ }
+ },
onClickChip = {},
)
},
@@ -198,18 +227,42 @@ fun NodeListScreen(
val isActive = remember(activeNodeId, node.num) { activeNodeId == node.num }
- NodeItem(
- modifier = Modifier.animateItem(),
- thisNode = ourNode,
- thatNode = node,
- distanceUnits = state.distanceUnits,
- tempInFahrenheit = state.tempInFahrenheit,
- onClick = { navigateToNodeDetails(node.num) },
- onLongClick = longClick,
- connectionState = connectionState,
- deviceType = deviceType,
- isActive = isActive,
- )
+ when (density) {
+ NodeListDensity.COMPLETE ->
+ NodeItem(
+ modifier = Modifier.animateItem(),
+ thisNode = ourNode,
+ thatNode = node,
+ distanceUnits = state.distanceUnits,
+ tempInFahrenheit = state.tempInFahrenheit,
+ onClick = { navigateToNodeDetails(node.num) },
+ onLongClick = longClick,
+ connectionState = connectionState,
+ deviceType = deviceType,
+ isActive = isActive,
+ )
+
+ NodeListDensity.COMPACT ->
+ NodeItemCompact(
+ modifier = Modifier.animateItem(),
+ thisNode = ourNode,
+ thatNode = node,
+ distanceUnits = state.distanceUnits,
+ onClick = { navigateToNodeDetails(node.num) },
+ onLongClick = longClick,
+ connectionState = connectionState,
+ deviceType = deviceType,
+ isActive = isActive,
+ showPower = showPower,
+ showLastHeard = showLastHeard,
+ showLocation = showLocation,
+ showHops = showHops,
+ showSignal = showSignal,
+ showChannel = showChannel,
+ showRole = showRole,
+ showTelemetry = showTelemetry,
+ )
+ }
val isThisNode = remember(node) { ourNode?.num == node.num }
if (!isThisNode) {
NodeContextMenu(

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
index b2eb915107..edf3a7e4ab 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
@@ -29,6 +29,7 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.NodeListDensity
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
@@ -70,6 +71,21 @@ class NodeListViewModel(
.map { address -> address?.let { DeviceType.fromAddress(it) } }
.stateInWhileSubscribed(initialValue = null)
+ val nodeListDensity: StateFlow<NodeListDensity> =
+ nodeFilterPreferences.nodeListDensity
+ .map { name -> NodeListDensity.entries.firstOrNull { it.name == name } ?: NodeListDensity.COMPLETE }
+ .stateInWhileSubscribed(initialValue = NodeListDensity.COMPLETE)
+
+ val shouldShowPower = nodeFilterPreferences.shouldShowPower
+ val shouldShowLastHeard = nodeFilterPreferences.shouldShowLastHeard
+ val lastHeardIsRelative = nodeFilterPreferences.lastHeardIsRelative
+ val shouldShowLocation = nodeFilterPreferences.shouldShowLocation
+ val shouldShowHops = nodeFilterPreferences.shouldShowHops
+ val shouldShowSignal = nodeFilterPreferences.shouldShowSignal
+ val shouldShowChannel = nodeFilterPreferences.shouldShowChannel
+ val shouldShowRole = nodeFilterPreferences.shouldShowRole
+ val shouldShowTelemetry = nodeFilterPreferences.shouldShowTelemetry
+
private val nodeSortOption = nodeFilterPreferences.nodeSortOption
private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "")

diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index 05a4c05a4c..93020166f4 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -62,6 +62,7 @@ import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.feature.settings.component.AppInfoSection
import org.meshtastic.feature.settings.component.AppearanceSection
import org.meshtastic.feature.settings.component.ExpressiveSection
+import org.meshtastic.feature.settings.component.NodeLayoutSettings
import org.meshtastic.feature.settings.component.PersistenceSection
import org.meshtastic.feature.settings.component.PrivacySection
import org.meshtastic.feature.settings.component.ThemePickerDialog
@@ -240,6 +241,33 @@ fun SettingsScreen(
onShowThemePicker = { showThemePickerDialog = true },
)
+ val densityName by settingsViewModel.nodeListDensity.collectAsStateWithLifecycle()
+ val density =
+ org.meshtastic.core.model.NodeListDensity.entries.firstOrNull { it.name == densityName }
+ ?: org.meshtastic.core.model.NodeListDensity.COMPLETE
+ NodeLayoutSettings(
+ density = density,
+ onDensityChange = { settingsViewModel.setNodeListDensity(it.name) },
+ showPower = settingsViewModel.shouldShowPower.collectAsStateWithLifecycle().value,
+ onShowPowerChange = { settingsViewModel.setShouldShowPower(it) },
+ showLastHeard = settingsViewModel.shouldShowLastHeard.collectAsStateWithLifecycle().value,
+ onShowLastHeardChange = { settingsViewModel.setShouldShowLastHeard(it) },
+ lastHeardIsRelative = settingsViewModel.lastHeardIsRelative.collectAsStateWithLifecycle().value,
+ onLastHeardIsRelativeChange = { settingsViewModel.setLastHeardIsRelative(it) },
+ showLocation = settingsViewModel.shouldShowLocation.collectAsStateWithLifecycle().value,
+ onShowLocationChange = { settingsViewModel.setShouldShowLocation(it) },
+ showHops = settingsViewModel.shouldShowHops.collectAsStateWithLifecycle().value,
+ onShowHopsChange = { settingsViewModel.setShouldShowHops(it) },
+ showSignal = settingsViewModel.shouldShowSignal.collectAsStateWithLifecycle().value,
+ onShowSignalChange = { settingsViewModel.setShouldShowSignal(it) },
+ showChannel = settingsViewModel.shouldShowChannel.collectAsStateWithLifecycle().value,
+ onShowChannelChange = { settingsViewModel.setShouldShowChannel(it) },
+ showRole = settingsViewModel.shouldShowRole.collectAsStateWithLifecycle().value,
+ onShowRoleChange = { settingsViewModel.setShouldShowRole(it) },
+ showTelemetry = settingsViewModel.shouldShowTelemetry.collectAsStateWithLifecycle().value,
+ onShowTelemetryChange = { settingsViewModel.setShouldShowTelemetry(it) },
+ )
+
ExpressiveSection(title = stringResource(Res.string.wifi_devices)) {
ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = MeshtasticIcons.Wifi) {
onNavigate(WifiProvisionRoute.WifiProvision())

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
index be5ca8c790..afd92c322e 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
@@ -191,4 +191,36 @@ class SettingsViewModel(
val myNodeNum = myNodeNum ?: return
exportDataUseCase(writer, myNodeNum, filterPortnum)
}
+
+ // Node list layout preferences
+ val nodeListDensity = uiPrefs.nodeListDensity
+ val shouldShowPower = uiPrefs.shouldShowPower
+ val shouldShowLastHeard = uiPrefs.shouldShowLastHeard
+ val lastHeardIsRelative = uiPrefs.lastHeardIsRelative
+ val shouldShowLocation = uiPrefs.shouldShowLocation
+ val shouldShowHops = uiPrefs.shouldShowHops
+ val shouldShowSignal = uiPrefs.shouldShowSignal
+ val shouldShowChannel = uiPrefs.shouldShowChannel
+ val shouldShowRole = uiPrefs.shouldShowRole
+ val shouldShowTelemetry = uiPrefs.shouldShowTelemetry
+
+ fun setNodeListDensity(value: String) = uiPrefs.setNodeListDensity(value)
+
+ fun setShouldShowPower(value: Boolean) = uiPrefs.setShouldShowPower(value)
+
+ fun setShouldShowLastHeard(value: Boolean) = uiPrefs.setShouldShowLastHeard(value)
+
+ fun setLastHeardIsRelative(value: Boolean) = uiPrefs.setLastHeardIsRelative(value)
+
+ fun setShouldShowLocation(value: Boolean) = uiPrefs.setShouldShowLocation(value)
+
+ fun setShouldShowHops(value: Boolean) = uiPrefs.setShouldShowHops(value)
+
+ fun setShouldShowSignal(value: Boolean) = uiPrefs.setShouldShowSignal(value)
+
+ fun setShouldShowChannel(value: Boolean) = uiPrefs.setShouldShowChannel(value)
+
+ fun setShouldShowRole(value: Boolean) = uiPrefs.setShouldShowRole(value)
+
+ fun setShouldShowTelemetry(value: Boolean) = uiPrefs.setShouldShowTelemetry(value)
}

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/NodeLayoutSettings.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/NodeLayoutSettings.kt
new file mode 100644
index 0000000000..977fa075ba
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/NodeLayoutSettings.kt
@@ -0,0 +1,158 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings.component
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.NodeListDensity
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.node_layout_channel
+import org.meshtastic.core.resources.node_layout_compact
+import org.meshtastic.core.resources.node_layout_complete
+import org.meshtastic.core.resources.node_layout_complete_description
+import org.meshtastic.core.resources.node_layout_device_role
+import org.meshtastic.core.resources.node_layout_distance_and_bearing
+import org.meshtastic.core.resources.node_layout_hops_away
+import org.meshtastic.core.resources.node_layout_last_heard_time
+import org.meshtastic.core.resources.node_layout_log_icons
+import org.meshtastic.core.resources.node_layout_power
+import org.meshtastic.core.resources.node_layout_relative_last_heard
+import org.meshtastic.core.resources.node_layout_section_title
+import org.meshtastic.core.resources.node_layout_signal_direct_only
+import org.meshtastic.core.ui.component.SwitchPreference
+
+/** Node layout density picker and compact field toggles for the Settings screen. */
+@Composable
+@Suppress("LongParameterList", "LongMethod")
+fun NodeLayoutSettings(
+ density: NodeListDensity,
+ onDensityChange: (NodeListDensity) -> Unit,
+ showPower: Boolean,
+ onShowPowerChange: (Boolean) -> Unit,
+ showLastHeard: Boolean,
+ onShowLastHeardChange: (Boolean) -> Unit,
+ lastHeardIsRelative: Boolean,
+ onLastHeardIsRelativeChange: (Boolean) -> Unit,
+ showLocation: Boolean,
+ onShowLocationChange: (Boolean) -> Unit,
+ showHops: Boolean,
+ onShowHopsChange: (Boolean) -> Unit,
+ showSignal: Boolean,
+ onShowSignalChange: (Boolean) -> Unit,
+ showChannel: Boolean,
+ onShowChannelChange: (Boolean) -> Unit,
+ showRole: Boolean,
+ onShowRoleChange: (Boolean) -> Unit,
+ showTelemetry: Boolean,
+ onShowTelemetryChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ ExpressiveSection(modifier = modifier, title = stringResource(Res.string.node_layout_section_title)) {
+ // Density picker
+ SingleChoiceSegmentedButtonRow(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ NodeListDensity.entries.forEachIndexed { index, option ->
+ val label =
+ when (option) {
+ NodeListDensity.COMPLETE -> stringResource(Res.string.node_layout_complete)
+ NodeListDensity.COMPACT -> stringResource(Res.string.node_layout_compact)
+ }
+ SegmentedButton(
+ shape = SegmentedButtonDefaults.itemShape(index, NodeListDensity.entries.size),
+ onClick = { onDensityChange(option) },
+ selected = density == option,
+ label = { Text(label) },
+ )
+ }
+ }
+
+ if (density == NodeListDensity.COMPLETE) {
+ Text(
+ text = stringResource(Res.string.node_layout_complete_description),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ } else {
+ // Compact toggles ordered by layout position
+ SwitchPreference(
+ title = stringResource(Res.string.node_layout_power),
+ checked = showPower,
+ enabled = true,
+ onCheckedChange = onShowPowerChange,
+ )
+ SwitchPreference(
+ title = stringResource(Res.string.node_layout_last_heard_time),
+ checked = showLastHeard,
+ enabled = true,
+ onCheckedChange = onShowLastHeardChange,
+ )
+ SwitchPreference(
+ title = stringResource(Res.string.node_layout_relative_last_heard),
+ checked = lastHeardIsRelative,
+ enabled = showLastHeard,
+ onCheckedChange = onLastHeardIsRelativeChange,
+ )
+ SwitchPreference(
+ title = stringResource(Res.string.node_layout_distance_and_bearing),
+ checked = showLocation,
+ enabled = true,
+ onCheckedChange = onShowLocationChange,
+ )
+ SwitchPreference(
+ title = stringResource(Res.string.node_layout_hops_away),
+ checked = showHops,
+ enabled = true,
+ onCheckedChange = onShowHopsChange,
+ )
+ SwitchPreference(
+ title = stringResource(Res.string.node_layout_signal_direct_only),
+ checked = showSignal,
+ enabled = true,
+ onCheckedChange = onShowSignalChange,
+ )
+ SwitchPreference(
+ title = stringResource(Res.string.node_layout_channel),
+ checked = showChannel,
+ enabled = true,
+ onCheckedChange = onShowChannelChange,
+ )
+ SwitchPreference(
+ title = stringResource(Res.string.node_layout_device_role),
+ checked = showRole,
+ enabled = true,
+ onCheckedChange = onShowRoleChange,
+ )
+ SwitchPreference(
+ title = stringResource(Res.string.node_layout_log_icons),
+ checked = showTelemetry,
+ enabled = true,
+ onCheckedChange = onShowTelemetryChange,
+ )
+ }
+ }
+}

diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
index 31ab16a16a..9791478639 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
@@ -71,6 +71,7 @@ import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.core.ui.util.rememberShowToastResource
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.component.HomoglyphSetting
+import org.meshtastic.feature.settings.component.NodeLayoutSettings
import org.meshtastic.feature.settings.component.NotificationSection
import org.meshtastic.feature.settings.component.ThemePickerDialog
import org.meshtastic.feature.settings.navigation.ConfigRoute
@@ -202,6 +203,33 @@ fun DesktopSettingsScreen(
)
}
+ val densityName by settingsViewModel.nodeListDensity.collectAsStateWithLifecycle()
+ val density =
+ org.meshtastic.core.model.NodeListDensity.entries.firstOrNull { it.name == densityName }
+ ?: org.meshtastic.core.model.NodeListDensity.COMPLETE
+ NodeLayoutSettings(
+ density = density,
+ onDensityChange = { settingsViewModel.setNodeListDensity(it.name) },
+ showPower = settingsViewModel.shouldShowPower.collectAsStateWithLifecycle().value,
+ onShowPowerChange = { settingsViewModel.setShouldShowPower(it) },
+ showLastHeard = settingsViewModel.shouldShowLastHeard.collectAsStateWithLifecycle().value,
+ onShowLastHeardChange = { settingsViewModel.setShouldShowLastHeard(it) },
+ lastHeardIsRelative = settingsViewModel.lastHeardIsRelative.collectAsStateWithLifecycle().value,
+ onLastHeardIsRelativeChange = { settingsViewModel.setLastHeardIsRelative(it) },
+ showLocation = settingsViewModel.shouldShowLocation.collectAsStateWithLifecycle().value,
+ onShowLocationChange = { settingsViewModel.setShouldShowLocation(it) },
+ showHops = settingsViewModel.shouldShowHops.collectAsStateWithLifecycle().value,
+ onShowHopsChange = { settingsViewModel.setShouldShowHops(it) },
+ showSignal = settingsViewModel.shouldShowSignal.collectAsStateWithLifecycle().value,
+ onShowSignalChange = { settingsViewModel.setShouldShowSignal(it) },
+ showChannel = settingsViewModel.shouldShowChannel.collectAsStateWithLifecycle().value,
+ onShowChannelChange = { settingsViewModel.setShouldShowChannel(it) },
+ showRole = settingsViewModel.shouldShowRole.collectAsStateWithLifecycle().value,
+ onShowRoleChange = { settingsViewModel.setShouldShowRole(it) },
+ showTelemetry = settingsViewModel.shouldShowTelemetry.collectAsStateWithLifecycle().value,
+ onShowTelemetryChange = { settingsViewModel.setShouldShowTelemetry(it) },
+ )
+
ExpressiveSection(title = stringResource(Res.string.wifi_devices)) {
ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = MeshtasticIcons.Wifi) {
onNavigate(WifiProvisionRoute.WifiProvision())

Served by rngit 1.5.0 - Generated in 0.32s